3주차. 멀티모달 RAG — 이미지·표·복합 문서 처리
출처: 원서 『RAG 마스터: 랭체인으로 완성하는 LLM 서비스』(프리렉 2025) 3장 개념 + NewRunDev 실전 라이브 강의 3주차 실습 노트북(
practice3-1.ipynb) 이 노트는 원서 3장의 개념 골격을 따르되, 강의가 프로덕션 로컬 스택(LM Studio 로컬 LLM·Chroma 서버·결정적 ID·재시도 적재)으로 다시 구현한 지점을 "책은 A, 강의는 B — 왜"로 대조한다. 이 대조가 이 강의의 핵심 가치다.
학습 목표
이 주차를 끝내면 다음을 할 수 있다.
- 멀티모달 RAG가 텍스트 전용 RAG와 왜, 어떻게 다른지 설명한다.
- 세 가지 구현 전략(동일 벡터 공간·기본 모달리티 통일·별도 저장소)을 비교하고 상황에 맞게 고른다.
unstructured의partition_pdf로 한글 PDF에서 텍스트·표·이미지를 추출한다(한글 OCR 포함).- OpenAI API 대신 LM Studio 로컬 멀티모달 LLM·임베딩을 OpenAI 호환 엔드포인트로 붙여 요약을 생성한다.
- 요약을 임베딩해 검색하고 원본을 반환하는
MultiVectorRetriever를 Chroma 서버로 구성한다. - 재실행에 안전한 파이프라인(결정적 ID·기존 컬렉션 초기화·배치 재시도 적재)을 만든다.
- 검색된 텍스트+이미지(base64)를 하나의 멀티모달 메시지로 조합해 답변을 생성한다.
전체 흐름도
[ 한글 PDF (텍스트·표·이미지 혼재) — 말라리아 주간소식지 ]
│ partition_pdf(languages=["kor","eng"]) ← 한글 OCR
│ 결과는 pickle 로 캐시 (추출은 비싸다)
▼
┌───────────────────────────────────────────────┐
│ 텍스트 35 │ 테이블 10 │ 이미지 43(.jpg) │
└───────────────────────────────────────────────┘
│ 로컬 LLM(gemma) 요약 + strip_think()
│ 텍스트/표 → 요약, 이미지 → base64 → 비전 요약
▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ vectorstore │ │ docstore │
│ = Chroma 서버(HttpClient)│ │ = InMemoryStore │
│ 요약 임베딩(qwen3 4096d) │ │ 원본 텍스트·표·img base64│
└──────────────────────────┘ └──────────────────────────┘
│ 결정적 ID(SHA-1)로 요약↔원본 연결
│ safe_add: 배치4 + 지수 백오프 재시도
▼
[ MultiVectorRetriever ] 요약으로 검색 → doc_id → 원본 반환
│ split_image_text_types (b64decode validate=True)
▼
[ 로컬 비전 LLM ] ← 텍스트/표 + 이미지 1장을 멀티모달 메시지로
│
▼
[ 최종 응답 ] + strip_think()
0. 사전 필수 용어
- 모달리티(Modality) — 데이터의 형태·유형. 텍스트, 이미지, 표, 차트가 각각 하나의 모달리티다.
- 멀티모달 LLM(MLLM) — 텍스트뿐 아니라 이미지 등을 입력으로 받아 처리하는 LLM. 이 강의에서는 로컬 모델
gemma-4-e2b-it이 비전 입력을 담당한다. - MultiVectorRetriever — 요약본(vectorstore)으로 검색하고 원본(docstore)을 돌려주는 랭체인 검색기. 멀티모달 RAG의 핵심 컴포넌트.
- vectorstore / docstore — vectorstore는 "검색용 요약 임베딩"을, docstore는 "답변에 쓸 원본"을 담는다. 둘은
id_key(여기서는"doc_id")로 연결된다. - Base64 인코딩 — 이미지 이진 데이터를 ASCII 텍스트로 변환하는 방식. LLM API에 이미지를 실어 보낼 때
data:image/jpeg;base64,...형태로 쓴다. - unstructured / partition_pdf — 비정형 문서에서 텍스트·표·이미지를 자동 분류 추출하는 라이브러리와 그 핵심 함수.
- OpenAI 호환 엔드포인트 — LM Studio 같은 로컬 서버가 OpenAI와 동일한 REST 규격(
/v1/chat/completions,/v1/embeddings)을 흉내 내는 것. 덕분에ChatOpenAI의base_url만 로컬 주소로 바꾸면 코드 변경 없이 로컬 모델을 쓴다. - 추론(사고) 모델과
<think>블록 — qwen3 같은 추론 모델은 답변 앞에<think>...</think>사고 과정을 붙여 내보낸다. 그대로 임베딩·표시하면 오염되므로 잘라내야 한다. - 결정적 ID(deterministic ID) — 같은 원본 콘텐츠에 항상 같은 ID를 부여하는 방식. 여기서는 SHA-1 해시를 쓴다.
uuid4(매번 랜덤)와 대비된다.
1. 멀티모달 RAG 개요
1.1 무엇이 다른가
멀티모달 RAG는 텍스트 정보에만 의존하던 기존 RAG를, 이미지·차트·표 등 시각적·구조적 데이터까지 포함해 검색·생성하도록 확장한 기법이다. 상품 설명 텍스트만으로 검색하는 것보다 상품 사진까지 함께 쓰면 더 정확히 찾을 수 있듯이, LLM에게 이미지·차트·표를 함께 주면 더 정확한 맥락과 다차원 답변을 만들 수 있다.
1.2 왜 어려운가
실무 비정형 문서는 고해상도 이미지·텍스트·표·차트·다이어그램이 한 PDF에 뒤섞여 있다. 두 가지가 텍스트 전용 RAG보다 까다롭다.
- 유형별 처리 요구가 다르다. 풍경 사진은 "전반적 개요"로 요약하면 되지만, 보고서 차트는 수치의 의미와 분석 맥락까지 해석해야 한다. 모달리티마다 다른 전략이 필요하다.
- 데이터 일관성. 차트를 설명하는 텍스트와 차트 자체 의미가 상충하면 LLM이 혼란스러운 답을 낸다. 모달리티 간 정보가 일관되게 관리돼야 한다.
이번 실습 데이터는 질병관리청 말라리아 주간소식지 PDF로, 본문 텍스트·주차별 통계표·연령별/지역별 차트 이미지가 섞여 있다.
2. 세 가지 구현 전략과 강의의 선택
멀티모달 RAG의 핵심 질문은 하나다: 서로 다른 모달리티를 LLM에게 어떻게 인지시킬 것인가. 답에 따라 세 전략이 있다.
| 전략 | 핵심 아이디어 | 장점 | 단점 | 복잡도 |
|---|---|---|---|---|
| A. 동일 벡터 공간 | CLIP 등 멀티모달 임베딩으로 이미지·텍스트를 한 벡터 저장소에 | 기존 RAG 인프라 재사용, 구현 단순 | 이미지·텍스트·복잡한 표를 정확히 임베딩할 강력한 모델 확보가 필수 | 낮음 |
| B. 기본 모달리티 통일 | 이미지를 LLM으로 텍스트 요약 후 텍스트만 임베딩 | 명확한 질의에 효과적, 별도 이미지 임베딩 모델 불필요 | 전처리 LLM 비용, 이미지 세부 뉘앙스 손실 가능 | 중간 |
| C. 별도 저장소 | A+B 결합. 두 저장소에서 검색 후 리랭킹 | 각 방식 장점 결합, 최고 품질 | 전처리 시간·인프라 비용 배증 | 높음 |
원서와 강의 모두 전략 B를 택한다. 이미지를 멀티모달 LLM으로 텍스트 요약한 뒤, 그 요약을 임베딩해 MultiVectorRetriever로 검색한다. 차이는 "무엇으로 요약·임베딩하느냐"다.
책은 gpt-4o(OpenAI 유료 API), 강의는 로컬 모델(LM Studio). 전략 B가 지적하는 "전처리 LLM 비용"이 강의에서는 사실상 0이 된다. 이미지·표가 많은 문서일수록 전처리 호출이 수백 건이 되는데, 이를 유료 API로 돌리면 비용이 크고 데이터가 외부로 나간다. 강의는 이 비용·프라이버시 문제를 로컬 LLM으로 정면 해결한다. 아래 3~10절은 이 "전략 B를 로컬 프로덕션으로 구현하는 법"의 상세다.
3. 환경 구성 — 로컬 스택 (강의 고유)
3.1 로컬 시스템 의존성: conda-forge tesseract·poppler
unstructured는 이미지 속 글자를 읽는 OCR 엔진 tesseract와 PDF를 이미지로 렌더링하는 poppler에 의존한다. 강의는 이를 시스템 apt(root 필요)가 아니라 conda-forge 채널로 사용자 환경에 설치한다.
import os
# 홈에 설치한 miniforge의 bin을 PATH 맨 앞에 둬야 노트북의 ! 셸이 conda·tesseract를 찾는다
os.environ["PATH"] = os.path.expanduser("~/miniforge3/bin") + ":" + os.environ["PATH"]
# root·apt 없이 커널이 접근 가능한 사용자 환경에 OCR/PDF 바이너리를 넣는다
# 컨테이너·공용 서버처럼 sudo 권한이 없는 환경에서 이 방식이 유일한 설치 경로가 된다
conda install -y -q -c conda-forge tesseract poppler
tesseract --list-langs # kor(한글) 언어팩이 목록에 있어야 한글 OCR이 동작한다
책은
sudo apt install tesseract-ocr poppler-utils, 강의는conda install -c conda-forge. 책은 로컬 관리자 권한을 전제하지만, 강의 실행 환경은 root 없는 컨테이너(/home/unsloth/...)다. conda-forge 설치는 커널과 같은 사용자 공간에 바이너리를 두므로 권한 문제와 시스템 오염을 동시에 피한다.tesseract --list-langs에kor이 보이는지 확인하는 절차가 한글 문서 처리의 사전 관문이다.
3.2 로컬 LLM·임베딩을 OpenAI 호환으로 연결
LM Studio가 띄운 로컬 서버를 /v1/models로 먼저 확인한다. 이 강의 환경에는 비전 채팅 모델 gemma-4-e2b-it과 임베딩 모델 text-embedding-qwen3-embedding-8b이 올라와 있다.
LMSTUDIO_BASE_URL = "http://host.docker.internal:12345/v1" # 컨테이너에서 호스트의 LM Studio로
LMSTUDIO_API_KEY = "lm-studio" # 로컬 서버는 키를 검증하지 않는다. 형식만 맞춘 더미
EMBED_MODEL = "embedding-8b:sl" # LM Studio에 로드된 qwen3 임베딩의 로컬 별칭
LLM_MODEL = "gemma-4-e2b-it" # 비전 입력을 받는 로컬 멀티모달 모델
import re
def strip_think(text: str) -> str:
# 추론 모델이 답 앞에 붙이는 <think>...</think> 사고 과정을 제거한다
# 이 블록을 그대로 두면 임베딩이 사고 노이즈로 오염되고, 최종 답변에도 잡음이 섞인다
if text is None:
return ""
return re.sub(r"(^|<think>).*?</think>", "", text, flags=re.DOTALL).strip()
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
llm = ChatOpenAI(
model=LLM_MODEL,
base_url=LMSTUDIO_BASE_URL, # 이 한 줄이 OpenAI 클라우드 대신 로컬 서버로 요청을 돌린다
api_key=LMSTUDIO_API_KEY,
temperature=0,
max_tokens=1024,
)
embeddings = OpenAIEmbeddings(
model=EMBED_MODEL,
base_url=LMSTUDIO_BASE_URL,
api_key=LMSTUDIO_API_KEY,
# 로컬 서버는 OpenAI의 tiktoken 토큰 배열 입력을 못 알아듣는다
# False로 두면 원문 문자열을 그대로 보내 LM Studio와 호환된다 — 로컬 임베딩의 필수 스위치
check_embedding_ctx_length=False,
)
책은
ChatOpenAI(model="gpt-4o")/OpenAIEmbeddings()기본값, 강의는base_url을 로컬로 바꾸고check_embedding_ctx_length=False를 추가한다. 핵심 통찰은 "LangChain의ChatOpenAI는 OpenAI 전용이 아니라 OpenAI 호환 규격 클라이언트"라는 점이다.base_url만 바꾸면 코드 로직은 그대로 두고 백엔드만 클라우드↔로컬로 교체할 수 있다.check_embedding_ctx_length=False는 로컬 임베딩 서버가 tiktoken 토큰 배열이 아닌 원문 문자열을 기대하기 때문에 반드시 필요하다 — 빠뜨리면 임베딩 호출이 깨진다.strip_think은 클라우드 gpt-4o에는 없던, 로컬 추론 모델을 쓰기 때문에 새로 필요해진 방어 장치다.
4. PDF 요소 추출 — partition_pdf
partition_pdf 한 번으로 텍스트·표·이미지를 함께 뽑는다. 추출은 OCR·이미지 렌더링을 동반해 느리므로, 강의는 결과를 pickle로 캐시해 재실행 시 건너뛴다.
import os, pickle
from unstructured.partition.pdf import partition_pdf
IMG_DIR = "practice3"
CACHE = os.path.join(IMG_DIR, "raw_pdf_elements.pkl")
if os.path.exists(CACHE):
with open(CACHE, "rb") as f:
raw_pdf_elements = pickle.load(f) # 재실행 시 값비싼 재추출을 건너뛴다
else:
raw_pdf_elements = partition_pdf(
filename=os.path.join(FPATH, FNAME),
extract_images_in_pdf=True, # False면 PDF 속 이미지는 그냥 버려진다
infer_table_structure=True, # 표를 통짜 글덩이가 아니라 행·열 구조로 읽는다
chunking_strategy="by_title", # 글자 수가 아니라 제목 경계에서 자른다 — 문맥이 중간에 끊기지 않게
extract_image_block_output_dir=IMG_DIR,
languages=["kor", "eng"], # 한글+영문 OCR. 이 인자가 없으면 한글 이미지가 깨져 읽힌다
)
with open(CACHE, "wb") as f:
pickle.dump(raw_pdf_elements, f)
추출된 요소는 클래스 타입 문자열로 표/텍스트를 가른다.
tables, texts = [], []
for element in raw_pdf_elements:
t = str(type(element))
# unstructured는 요소마다 다른 클래스를 돌려준다. 클래스명 문자열로 종류를 판별한다
if "unstructured.documents.elements.Table" in t:
tables.append(str(element))
elif "unstructured.documents.elements.CompositeElement" in t:
texts.append(str(element))
# 이 PDF 기준: tables 10개 / texts 35개, 이미지 파일 43개가 IMG_DIR에 .jpg로 저장됨
책은
languages없이 영문 기준, 강의는languages=["kor","eng"]. 대상이 한글 질병관리청 문서라 이 인자가 결정적이다. 없으면 표·차트 속 한글이 깨져 요약 품질이 무너진다. 또 책에는 없는 pickle 캐싱을 강의가 넣은 이유는,partition_pdf가 OCR+이미지 렌더링으로 수십 초 이상 걸려 노트북을 반복 실행하는 개발 흐름에서 병목이 되기 때문이다. 캐시 파일 하나로 두 번째 실행부터 즉시 로드된다.
5. 텍스트·표 요약
원본을 그대로 임베딩하면 길고 잡음이 많아 검색 품질이 낮다. 검색에 최적화된 짧은 요약을 LLM으로 만들어 임베딩한다.
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
prompt_text = """당신은 표와 텍스트를 요약하여 검색할 수 있도록 돕는 어시스턴트입니다.
이 요약은 임베딩되어 원본 텍스트나 표 요소를 검색하는 데 사용될 것입니다.
표 또는 텍스트에 대한 간결한 요약을 제공하여 검색에 최적화된 형태로 만들어 주세요.
표 또는 텍스트: {element}"""
prompt = ChatPromptTemplate.from_template(prompt_text)
# {"element": lambda x: x}로 원문을 손대지 않고 프롬프트 자리에 끼운다
# 체인 끝의 strip_think로 로컬 추론 모델의 사고 블록을 걷어낸다 — 요약 임베딩 오염을 막는 마지막 관문
summarize_chain = (
{"element": lambda x: x}
| prompt
| llm
| StrOutputParser()
| (lambda s: strip_think(s))
)
# batch로 목록 전체를 한꺼번에 요약한다. max_concurrency=20은 동시 요청 상한
# 로컬 GPU는 동시 요청이 많으면 VRAM·큐가 포화되므로 이 값이 안정성의 손잡이가 된다
text_summaries = summarize_chain.batch(texts, {"max_concurrency": 20}) if texts else []
table_summaries = summarize_chain.batch(tables, {"max_concurrency": 20}) if tables else []
책은 gpt-4o-mini로 요약하고
max_concurrency=5, 강의는 로컬 gemma로 요약하고max_concurrency=20+ 끝에strip_think. 동시성 값의 의미가 다르다. 책의 5는 유료 API의 rate limit(요청 폭주 시 거부)을 피하기 위한 값이고, 강의의 20은 로컬 GPU 처리량에 맞춘 값이다. 로컬은 과금은 없지만 VRAM·큐 한계가 있어, 동시성이 너무 높으면 타임아웃·OOM이 난다.strip_think을 체인 끝에 붙인 것은 로컬 추론 모델 특유의 사고 블록을 요약 단계에서부터 제거해야 하기 때문이다.
6. 이미지 요약 — base64 + 로컬 비전
partition_pdf가 저장한 이미지들을 base64로 인코딩해, 로컬 비전 모델에 data:image/jpeg;base64,... 형태로 실어 보낸다.
import base64
from langchain_core.messages import HumanMessage
def encode_image(image_path: str) -> str:
with open(image_path, "rb") as image_file:
return base64.b64encode(image_file.read()).decode("utf-8")
# IMG_DIR의 이미지 파일을 정렬해 인코딩한다. 이 폴더에 무관한 이미지가 섞이면 그것까지 요약 대상이 된다
img_base64_list = []
for img_file in sorted(os.listdir(IMG_DIR)):
if img_file.lower().endswith((".jpg", ".jpeg", ".png")):
img_base64_list.append(encode_image(os.path.join(IMG_DIR, img_file)))
IMAGE_PROMPT = """당신은 이미지를 요약하여 검색을 위해 사용할 수 있도록 돕는 어시스턴트입니다.
이 요약은 임베딩되어 원본 이미지를 검색하는 데 사용됩니다.
이미지 검색에 최적화된 간결한 요약을 작성하세요."""
def image_summarize(img_base64: str) -> str:
# content를 리스트로 주는 이유: 한 메시지 안에 '지시 텍스트'와 '이미지'를 함께 담기 위해서다
# 텍스트 지시와 이미지가 같은 메시지에 있어야 모델이 "이 이미지를 이렇게 요약하라"를 이해한다
msg = llm.invoke([
HumanMessage(content=[
{"type": "text", "text": IMAGE_PROMPT},
{"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{img_base64}"}},
])
])
return strip_think(msg.content)
image_summaries = [image_summarize(b) for b in img_base64_list]
책은 gpt-4o 비전으로 이미지를 요약, 강의는 로컬
gemma-4-e2b-it비전으로 요약한다. 멀티모달 메시지 구조(content가 텍스트+이미지 원소의 리스트)는 OpenAI 호환 규격이라 코드가 동일하다. 즉 로컬 비전 모델도 OpenAI와 같은 메시지 포맷을 그대로 쓴다는 것이 이 부분의 실전 교훈이다. 다만 로컬 소형 비전 모델은 gpt-4o보다 세밀도가 낮을 수 있어, 요약 품질이 곧 검색 품질이라는 원칙(전략 B의 급소)이 로컬 환경에서 더 크게 작용한다.
7. 멀티벡터 검색기 — Chroma 서버 + 결정적 ID
vectorstore(요약 임베딩)와 docstore(원본)를 분리하고 id_key로 잇는 구조는 원서와 같다. 강의는 두 가지를 프로덕션화한다: Chroma를 서버 모드로 붙이고, ID를 콘텐츠 해시로 결정적으로 만든다.
import chromadb
from langchain_core.stores import InMemoryStore
from langchain_chroma import Chroma # 구 langchain_community 대신 독립 패키지
# MultiVectorRetriever 위치가 LangChain 버전마다 달라 순서대로 시도한다
# 버전 이동으로 import 경로가 바뀌어도 노트북이 깨지지 않게 하는 방어
try:
from langchain_classic.retrievers.multi_vector import MultiVectorRetriever
except ImportError:
try:
from langchain.retrievers.multi_vector import MultiVectorRetriever
except ImportError:
from langchain.retrievers import MultiVectorRetriever
# 같은 Docker 네트워크의 Chroma 컨테이너(chromadb:8000)에 HTTP로 붙는다
# in-process가 아니라 별도 서버라, 노트북 커널이 죽어도 벡터가 남고 여러 클라이언트가 공유한다
chroma_client = chromadb.HttpClient(host=CHROMA_HOST, port=CHROMA_PORT)
vectorstore = Chroma(
client=chroma_client,
collection_name="multi_modal_rag",
embedding_function=embeddings,
)
# docstore는 원본 보관용. InMemoryStore라 커널 종료 시 사라진다 — 실습용 선택
docstore = InMemoryStore()
id_key = "doc_id" # 요약(vectorstore)의 메타데이터에 이 키로 원본 id를 적어 둬야 검색 후 원본을 되찾는다
retriever = MultiVectorRetriever(vectorstore=vectorstore, docstore=docstore, id_key=id_key)
콘텐츠 기반 결정적 ID:
import hashlib
def _ids(prefix: str, items: list[str]) -> list[str]:
# 같은 원본이면 항상 같은 ID가 나온다 → 셀을 여러 번 돌려도 ID가 바뀌지 않아 중복 적재를 막는다
# prefix로 종류를 구분해 텍스트·표·이미지가 우연히 같은 해시여도 ID가 충돌하지 않는다
return [f"{prefix}-{hashlib.sha1(item.encode('utf-8')).hexdigest()[:16]}" for item in items]
doc_ids = _ids("text", texts)
table_ids = _ids("table", tables)
img_ids = _ids("img", img_base64_list)
재실행 안전을 위해 적재 전 기존 컬렉션을 비우고 docstore를 새로 만든다.
existing = vectorstore.get()
if existing and existing.get("ids"):
# 재실행 시 이전 벡터와 새 벡터가 중복으로 쌓이는 것을 막는다
vectorstore.delete(ids=existing["ids"])
retriever.docstore = InMemoryStore() # 원본 저장소도 함께 초기화해 vectorstore와 상태를 맞춘다
# i번째 요약이 i번째 원본을 가리키도록 순서를 맞춰 저장한다. 순서가 어긋나면 검색이 엉뚱한 원본을 낸다
if texts: retriever.docstore.mset(list(zip(doc_ids, texts)))
if tables: retriever.docstore.mset(list(zip(table_ids, tables)))
if img_base64_list: retriever.docstore.mset(list(zip(img_ids, img_base64_list)))
책은
uuid.uuid4()랜덤 ID + in-processChroma(), 강의는 SHA-1 결정적 ID + Chroma 서버(HttpClient). 두 변화 모두 "재실행 안전성"과 "운영성"을 노린다. uuid4는 셀을 다시 돌릴 때마다 새 ID를 만들어 같은 원본이 벡터 저장소에 중복 적재된다. SHA-1 콘텐츠 해시는 같은 원본에 항상 같은 ID를 줘,vectorstore.delete(기존)+ 재적재 시 정확히 같은 슬롯을 덮어쓴다(멱등). Chroma 서버 모드는 벡터를 별도 프로세스에 두어 커널이 죽어도 데이터가 살아있고 여러 클라이언트가 공유할 수 있어, 실습을 넘어선 서비스 구조에 가깝다. 단, docstore는 여전히InMemoryStore라 원본은 커널 종료 시 사라진다 — 실서비스라면 이 부분을 파일·DB 저장소로 바꿔야 한다.
8. 안정적 벡터 적재 — safe_add (강의 고유)
원서는 vectorstore.add_documents(summary_texts)로 한 번에 적재한다. 강의는 로컬 임베딩 엔드포인트가 일시적으로 흔들릴 수 있다는 현실을 반영해, 빈 요약 제거 → ID 중복 제거 → 작은 배치 → 실패 시 지수 백오프 재시도를 하는 safe_add를 쓴다.
import time
from langchain_core.documents import Document
def safe_add(summaries, ids, label, batch=4, retries=5) -> int:
# summaries와 ids 길이가 다르면 zip이 조용히 뒷부분을 버려 데이터가 누락된다 — 먼저 막는다
if len(summaries) != len(ids):
raise ValueError(f"[{label}] summaries·ids 개수 불일치")
# 빈 요약은 버리고, 같은 ID는 마지막 요약만 남긴다(dict 특성). 무의미·중복 벡터 차단
unique = {}
for s, i in zip(summaries, ids):
if s and s.strip():
unique[i] = s.strip()
items = list(unique.items())
if not items:
return 0
added = 0
for start in range(0, len(items), batch):
cur = items[start:start + batch]
docs = [
# metadata[id_key]가 검색 후 원본을 되찾는 연결 고리다. content_type은 나중에 종류별 필터에 쓸 수 있다
Document(page_content=s, metadata={id_key: i, "content_type": label})
for i, s in cur
]
vids = [i for i, _ in cur]
for attempt in range(retries):
try:
# page_content가 임베딩되고, metadata·vids와 함께 Chroma에 저장된다
# 같은 vids면 갱신이라 재실행해도 중복이 쌓이지 않는다
vectorstore.add_documents(documents=docs, ids=vids)
added += len(docs)
break
except Exception as exc:
# 로컬 엔드포인트 순간 부하·타임아웃을 1·2·4·8·16초로 물러서며 견딘다
if attempt < retries - 1:
time.sleep(2 ** attempt)
else:
# for-else: break 없이 끝났다 = 모든 재시도 실패. 그래도 예외를 던지지 않고 넘어간다
print(f"[{label}] 배치 최종 실패: {vids}")
return added
text_count = safe_add(text_summaries, doc_ids, "text")
table_count = safe_add(table_summaries, table_ids, "table")
image_count = safe_add(image_summaries, img_ids, "img")
책은
add_documents일괄 호출, 강의는 배치+재시도 래퍼safe_add. 차이의 근원은 백엔드의 신뢰도다. OpenAI 클라우드는 안정적이라 일괄 호출로 충분하지만, 로컬 LM Studio 임베딩은 VRAM 부족·모델 언로드·순간 부하로 간헐 실패할 수 있다.safe_add는 (1) 작은 배치로 한 번에 실패하는 양을 줄이고, (2) 지수 백오프로 일시 장애를 견디며, (3) 최종 실패해도 예외로 전체를 멈추지 않고 성공분만 세어 반환한다. 또 길이 불일치·빈 요약·ID 중복을 미리 걸러 "조용한 데이터 누락"을 원천 차단한다. 이것이 실습 코드와 프로덕션 코드의 전형적 차이다.
9. 검색과 분류 — split_image_text_types
검색 결과는 원본 텍스트/표(문자열)와 이미지(base64 문자열)가 섞여 나온다. base64로 디코딩되는지로 이미지/텍스트를 가른다.
from base64 import b64decode
QUESTION = "연령별 말라리아 국내발생 현황?"
docs = retriever.invoke(QUESTION) # 요약으로 검색 → doc_id → 원본 반환
def split_image_text_types(docs):
b64, text = [], []
for doc in docs:
try:
# validate=True: base64 문자셋이 아닌 글자가 하나라도 있으면 예외 → 이미지 오분류를 줄인다
b64decode(doc, validate=True)
b64.append(doc)
except Exception:
text.append(doc)
return {"images": b64, "texts": text}
docs_by_type = split_image_text_types(docs) # 예: images 1 / texts 3
책은
b64decode(doc), 강의는b64decode(doc, validate=True). 이 판별의 근본 약점은 "일반 텍스트가 우연히 base64 문자셋으로만 이뤄지면 이미지로 오분류"되는 것이다.validate=True는 base64에 속하지 않는 문자가 있으면 즉시 예외를 내 오분류 확률을 낮춘다(완전히 없애지는 못한다). 더 견고한 근본책은 docstore 저장 시 메타데이터에 타입을 명시해 그 값으로 분류하는 것이며,safe_add가 이미content_type을 심어두어 이 개선의 토대를 마련해 둔 셈이다.
10. 멀티모달 RAG 파이프라인 완성
검색된 텍스트/표와 이미지 1장을 하나의 멀티모달 메시지로 조합해 로컬 비전 LLM에 넘긴다.
from langchain_core.runnables import RunnablePassthrough, RunnableLambda
def prompt_func(data):
format_texts = "\n".join(data["context"]["texts"])
text = f"""다음 문맥에만 기반하여 질문에 답하세요. 문맥에는 텍스트, 표, 그리고 아래 이미지가 포함될 수 있습니다.
질문: {data['question']}
텍스트와 표:
{format_texts}
"""
content = [{"type": "text", "text": text}]
# 검색된 이미지가 여러 장이어도 [0] 한 장만 실어 보낸다 — 나머지는 버려진다(소형 모델 컨텍스트 보호)
if data["context"]["images"]:
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{data['context']['images'][0]}"},
})
return [HumanMessage(content=content)]
# context 갈래는 검색→이미지/텍스트 분류를 거치고, question 갈래는 원 질문을 그대로 통과시킨다
# 두 갈래가 prompt_func에서 하나의 멀티모달 메시지로 합쳐진다
chain = (
{
"context": retriever | RunnableLambda(split_image_text_types),
"question": RunnablePassthrough(),
}
| RunnableLambda(prompt_func)
| llm
| StrOutputParser()
| (lambda s: strip_think(s)) # 최종 답변에서도 사고 블록 제거
)
answer = chain.invoke(QUESTION)
# → 국내발생 연령별 현황: 평균 40.7세, 20대 209명(33.2%)으로 최다 …
RunnableLambda vs RunnablePassthrough
RunnableLambda— 임의의 파이썬 함수를 체인에 끼울 때 쓴다. 여기서는 검색 결과를 이미지/텍스트로 나누고(split_image_text_types), 멀티모달 메시지를 조립한다(prompt_func).RunnablePassthrough— 입력을 그대로 다음 단계로 흘려보낸다.question에 원 질문을 손대지 않고 전달할 때 쓴다.
책은 gpt-4o로 최종 답변, 강의는 로컬 gemma로 답변 + 끝에
strip_think. 파이프라인 뼈대(context/question 두 갈래 → 멀티모달 메시지 → 모델 → 파싱)는 원서와 동일하다. 강의가 더한 것은 답변 단계에도strip_think을 붙여 로컬 추론 모델의 사고 흔적을 사용자에게 노출하지 않는 것이다. "이미지는 첫 장만 보낸다"는 원서와 같은 한계이며, 소형 로컬 모델은 컨텍스트가 더 짧아 이 제약이 실전에서 더 크게 작용한다.
핵심 정리 — 책 vs 강의 대조표
| 축 | 책(원서 3장) | 강의(practice3-1) | 왜 |
|---|---|---|---|
| 채팅/비전 LLM | gpt-4o (OpenAI 유료) | LM Studio gemma-4-e2b-it (로컬) |
전처리 비용 0·프라이버시·오프라인 |
| 임베딩 | OpenAIEmbeddings() |
로컬 qwen3 4096차원 (check_embedding_ctx_length=False) |
로컬 서버 호환·무료 |
| 추론 잡음 | 없음 | strip_think() |
로컬 추론 모델의 <think> 제거 |
| OCR 언어 | 영문 기본 | languages=["kor","eng"] |
한글 문서 처리 |
| 시스템 의존성 | apt(root) |
conda-forge(rootless) | 컨테이너·무권한 환경 |
| 추출 반복 | 매번 실행 | pickle 캐시 | 값비싼 추출 재사용 |
| 벡터 저장소 | in-process Chroma() |
Chroma 서버(HttpClient) | 영속·공유 |
| ID | uuid4(랜덤) |
SHA-1 결정적 ID | 재실행 멱등·중복 방지 |
| 적재 | add_documents 일괄 |
safe_add 배치+재시도 |
로컬 엔드포인트 간헐 실패 대비 |
| b64 판별 | b64decode(doc) |
b64decode(doc, validate=True) + content_type 메타 |
오분류 축소 |
최신 동향 (검증 2026-07-21)
- 멀티모달 RAG의 개념·세 전략·
MultiVectorRetriever구조는 그대로 유효하다. 변하는 것은 "무엇으로 요약·임베딩하느냐"(모델)와 파싱 도구의 버전이다. - 로컬 멀티모달 스택이 현실 선택지가 됐다. LM Studio·Ollama 같은 로컬 서버가 OpenAI 호환 엔드포인트를 제공해,
ChatOpenAI의base_url만 바꿔 클라우드↔로컬을 오갈 수 있다. 이 강의가 그 실전 예다. - 비전 지원 모델·임베딩 모델의 종류·차원·API 세부는 빠르게 바뀐다. 최신 정보는 각 공식 문서에서 확인하라: LangChain(https://python.langchain.com/docs/), unstructured(https://docs.unstructured.io), Chroma(https://docs.trychroma.com). (구체 모델 버전·요금은 이 노트에서 단정하지 않는다 — 실행 환경의
/v1/models로 확인) ```
클릭하거나 Space를 눌러 뒤집기